D:\a\cssh-rs\cssh-rs\xtask\src\worktree_teardown.rs
Line | Count | Source |
1 | | //! Paseo worktree teardown. |
2 | | //! |
3 | | //! Paseo runs `worktree.teardown` commands in the host shell - |
4 | | //! PowerShell on Windows, bash on Linux/macOS - and `paseo.json` has |
5 | | //! no per-platform branching. Encoding the cleanup in a Rust binary |
6 | | //! lets the same `paseo.json` entry work on every platform: `git` |
7 | | //! invocations and env-var reads go through identical Rust APIs |
8 | | //! regardless of the host. |
9 | | //! |
10 | | //! The teardown does two things: |
11 | | //! |
12 | | //! 1. Detach `HEAD` in the worktree so the branch is not checked out |
13 | | //! when paseo removes the worktree. |
14 | | //! 2. Force-delete the worktree's branch from the source checkout so |
15 | | //! a future `paseo worktree create` can reuse the name without a |
16 | | //! manual `git branch -D`. |
17 | | //! |
18 | | //! Both steps are best-effort: a non-zero git exit or a failure to |
19 | | //! spawn `git` at all is logged but does not abort teardown, |
20 | | //! mirroring the previous shell scripts that used |
21 | | //! `; $global:LASTEXITCODE = 0` (PowerShell) and `|| true` (bash). |
22 | | //! Aborting would leave the worktree half-removed and force the |
23 | | //! contributor to clean up by hand. |
24 | | |
25 | | use std::path::{Path, PathBuf}; |
26 | | use std::process::Command; |
27 | | |
28 | | use anyhow::{Context, Result}; |
29 | | |
30 | | /// Env var paseo sets to the worktree being torn down. |
31 | | const ENV_WORKTREE_PATH: &str = "PASEO_WORKTREE_PATH"; |
32 | | |
33 | | /// Env var paseo sets to the source checkout (the original repo |
34 | | /// root, shared across worktrees). |
35 | | const ENV_SOURCE_CHECKOUT_PATH: &str = "PASEO_SOURCE_CHECKOUT_PATH"; |
36 | | |
37 | | /// Env var paseo sets to the branch name backing the worktree. |
38 | | const ENV_BRANCH_NAME: &str = "PASEO_BRANCH_NAME"; |
39 | | |
40 | | /// All side-effecting operations performed by this subcommand. |
41 | | /// |
42 | | /// Implement with mocks in tests to achieve zero filesystem, |
43 | | /// environment, or process side-effects. |
44 | | pub trait WorktreeTeardownSystem { |
45 | | /// Look up an environment variable. |
46 | | /// |
47 | | /// # Arguments |
48 | | /// |
49 | | /// * `key` - Environment variable name. |
50 | | /// |
51 | | /// # Returns |
52 | | /// |
53 | | /// `Some(value)` when the variable is set and non-empty, |
54 | | /// `None` otherwise. |
55 | | fn env_var(&self, key: &str) -> Option<String>; |
56 | | |
57 | | /// Run `git -C <repo_path> <args...>` and return the exit code. |
58 | | /// |
59 | | /// # Arguments |
60 | | /// |
61 | | /// * `repo_path` - Repository the git command targets. |
62 | | /// * `args` - Arguments passed after `-C <repo_path>`. Owned to |
63 | | /// keep the trait `mockall`-friendly (`&[&str]` introduces |
64 | | /// non-`'static` lifetimes that the mock generator rejects). |
65 | | /// |
66 | | /// # Returns |
67 | | /// |
68 | | /// The process exit code, or `-1` when the process was killed by |
69 | | /// a signal. |
70 | | /// |
71 | | /// # Errors |
72 | | /// |
73 | | /// Returns an error if the `git` binary cannot be spawned (for |
74 | | /// example, when it is not on `PATH`). |
75 | | fn run_git(&self, repo_path: &Path, args: Vec<String>) -> Result<i32>; |
76 | | } |
77 | | |
78 | | /// Production implementation of [`WorktreeTeardownSystem`]. |
79 | | pub struct RealSystem; |
80 | | |
81 | | #[cfg_attr(coverage_nightly, coverage(off))] |
82 | | impl WorktreeTeardownSystem for RealSystem { |
83 | | fn env_var(&self, key: &str) -> Option<String> { |
84 | | std::env::var(key).ok().filter(|v| !v.is_empty()) |
85 | | } |
86 | | |
87 | | fn run_git(&self, repo_path: &Path, args: Vec<String>) -> Result<i32> { |
88 | | let status = Command::new("git") |
89 | | .arg("-C") |
90 | | .arg(repo_path) |
91 | | .args(&args) |
92 | | .status() |
93 | | .with_context(|| format!("failed to spawn `git` for {}", repo_path.display()))?; |
94 | | Ok(status.code().unwrap_or(-1)) |
95 | | } |
96 | | } |
97 | | |
98 | | /// Tear down a paseo worktree by detaching `HEAD` and deleting the |
99 | | /// backing branch from the source checkout. |
100 | | /// |
101 | | /// Both git steps are best-effort: a non-zero exit code or a |
102 | | /// spawn failure (for example, `git` missing from `PATH`) is logged |
103 | | /// but does not abort the function. A missing env var means paseo |
104 | | /// did not invoke us (or invoked us with an incomplete environment); |
105 | | /// we log the gap and skip the affected step rather than failing. |
106 | | /// |
107 | | /// # Arguments |
108 | | /// |
109 | | /// * `system` - Injected I/O provider. |
110 | | /// |
111 | | /// # Returns |
112 | | /// |
113 | | /// `Ok(())` once every applicable step has been attempted. |
114 | 5 | pub fn worktree_teardown<S: WorktreeTeardownSystem>(system: &S) -> Result<()> { |
115 | 5 | let worktree_path = system.env_var(ENV_WORKTREE_PATH); |
116 | 5 | let source_path = system.env_var(ENV_SOURCE_CHECKOUT_PATH); |
117 | 5 | let branch_name = system.env_var(ENV_BRANCH_NAME); |
118 | | |
119 | 5 | if let Some(path4 ) = worktree_path.as_deref() { |
120 | 4 | run_git_best_effort( |
121 | 4 | system, |
122 | 4 | &PathBuf::from(path), |
123 | 4 | vec!["checkout".to_owned(), "--detach".to_owned()], |
124 | 4 | &format!("`git checkout --detach` in {path}"), |
125 | 4 | ); |
126 | 4 | } else { |
127 | 1 | log::info!("paseo worktree teardown: {ENV_WORKTREE_PATH} not set; skipping HEAD detach."); |
128 | | } |
129 | | |
130 | 5 | match (source_path.as_deref(), branch_name.as_deref()) { |
131 | 4 | (Some(source), Some(branch)) => { |
132 | 4 | run_git_best_effort( |
133 | 4 | system, |
134 | 4 | &PathBuf::from(source), |
135 | 4 | vec!["branch".to_owned(), "-D".to_owned(), branch.to_owned()], |
136 | 4 | &format!("`git branch -D {branch}` in {source}"), |
137 | 4 | ); |
138 | 4 | } |
139 | | _ => { |
140 | 1 | log::info!( |
141 | | "paseo worktree teardown: {ENV_SOURCE_CHECKOUT_PATH} or {ENV_BRANCH_NAME} not set; skipping branch delete." |
142 | | ); |
143 | | } |
144 | | } |
145 | | |
146 | 5 | Ok(()) |
147 | 5 | } |
148 | | |
149 | 8 | fn run_git_best_effort<S: WorktreeTeardownSystem>( |
150 | 8 | system: &S, |
151 | 8 | repo_path: &Path, |
152 | 8 | args: Vec<String>, |
153 | 8 | description: &str, |
154 | 8 | ) { |
155 | 8 | match system.run_git(repo_path, args) { |
156 | 4 | Ok(0) => {} |
157 | 2 | Ok(code) => { |
158 | 2 | log::warn!( |
159 | | "paseo worktree teardown: {description} exited with code {code}; continuing." |
160 | | ); |
161 | | } |
162 | 2 | Err(err) => { |
163 | 2 | log::warn!( |
164 | | "paseo worktree teardown: {description} failed to spawn `git`: {err:#}; continuing." |
165 | | ); |
166 | | } |
167 | | } |
168 | 8 | } |
169 | | |
170 | | #[cfg(test)] |
171 | | #[path = "tests/test_worktree_teardown.rs"] |
172 | | mod tests; |